Repository navigation
OSC 22 cursor support - #137
Merged
Merged
Conversation
Contributor
Changeset suggestionThe current changeset no longer matches this PR. This review contains a corrected replacement. Why: Changeset package scope does not match the affected packages confidently. Changeset contains implementation or roadmap noise. View the proposed changeset---
'@bomb.sh/tty': minor
---
Adds a `cursor` property to `open()` so the mouse pointer changes shape (via OSC 22) as it moves over elements.
When `term.capabilities.pointerShape` is `true` — either from the probe reply or from `detectTerminal()`'s built-in table for set-only terminals (ghostty, kitty, foot, xterm ≥ 367) — `render()` appends the resolved shape to `output` whenever it changes. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. For terminals not in the table, assert support manually:
```ts
term.update([{ type: "capability", key: "pointer-shape", value: true }]);
```
#### Migration
`cursor` is a new optional property on `open()`; existing call sites are unaffected. The `pointer` argument to `reduce()` is now passed inline rather than via a separate `setPointer()` call — this is an internal API change that only affects callers constructing a `Native` directly:
```diff
- native.reduce(statePtr, opsBuf, len, mode, row, dt);
- native.setPointer(x, y, down);
+ native.reduce(statePtr, opsBuf, len, mode, row, dt, x, y, pointer);
```
`setCapabilities()` now returns `boolean`; a `true` return means withdrawing `pointerShape` left a non-default shape on screen — write `POINTER_SHAPE_RESET` to the terminal to restore it.Review this changeset manually If this draft is correct, react with 🚀 and Cooper will commit it to this branch. |
| @@ -0,0 +1,11 @@ | |||
| --- | |||
Contributor
There was a problem hiding this comment.
Changeset needs revision.
Changeset package scope does not match the affected packages confidently. Changeset contains implementation or roadmap noise.
View the proposed replacement
---
'@bomb.sh/tty': minor
---
Adds a `cursor` property to `open()` so the mouse pointer changes shape (via OSC 22) as it moves over elements.
When `term.capabilities.pointerShape` is `true` — either from the probe reply or from `detectTerminal()`'s built-in table for set-only terminals (ghostty, kitty, foot, xterm ≥ 367) — `render()` appends the resolved shape to `output` whenever it changes. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. For terminals not in the table, assert support manually:
```ts
term.update([{ type: "capability", key: "pointer-shape", value: true }]);
```
#### Migration
`cursor` is a new optional property on `open()`; existing call sites are unaffected. The `pointer` argument to `reduce()` is now passed inline rather than via a separate `setPointer()` call — this is an internal API change that only affects callers constructing a `Native` directly:
```diff
- native.reduce(statePtr, opsBuf, len, mode, row, dt);
- native.setPointer(x, y, down);
+ native.reduce(statePtr, opsBuf, len, mode, row, dt, x, y, pointer);
```
`setCapabilities()` now returns `boolean`; a `true` return means withdrawing `pointerShape` left a non-default shape on screen — write `POINTER_SHAPE_RESET` to the terminal to restore it.
commit: |
|
Size Increased — +6.8 KB 129.4 KB unpacked |
natemoo-re
force-pushed
the
feat/pointer-shape
branch
from
October 9, 2026 15:32
50be391 to
926c307
Compare
Contributor
natemoo-re
force-pushed
the
feat/pointer-shape
branch
2 times, most recently
from
October 10, 2026 17:26
082e1e9 to
b9f09b4
Compare
natemoo-re
added this pull request to stack #145
October 10, 2026 17:49
natemoo-re
marked this pull request as ready for review
October 12, 2026 01:00
cowboyd
reviewed
Oct 15, 2026
cowboyd
approved these changes
Oct 16, 2026
…lity Adds renderer-spec §7.9 and wires it into the existing capability sections instead of a standalone feature spec: pointer shape is one more consumer of RuntimeCapabilities, gated the same way as the rest. - §7.9: open() gains pointerShape (kitty/CSS shape names; anything else is ignored and never reaches output). While capabilities.pointerShape is true, the last declaring element in hit-test order wins (innermost; capture-mode floats hide what is beneath, passthrough floats defer to it). Changes are appended to result.output as OSC 22 ; <shape> ST, so the host keeps writing one buffer (cowboyd on #101, replacing #101's separate result.cursor). There is no createTerm opt-in: the capability is the only gate. - Restore (dreyfus92 on #101): set-only OSC 22 cannot pop, so the Term sets "default" when the pointer leaves or is omitted, and update() returns the reset when the capability is withdrawn. Resize keeps the emitted shape since the terminal pointer is unaffected. - §7.3: output "may be" a view, since a frame carrying OSC 22 is assembled in TypeScript. - §7.7, §7.8, §11.2, §12.2 and terminfo-spec §6.3/§9.3 point at §7.9 instead of a "focused feature specification". terminfo-spec §6.3 documents the escape hatch for set-only terminals that never answer the query: term.update([{ type: "capability", key: "pointer-shape", value: true }]). "pointerShape" rather than "cursor": cursor already names the hardware text cursor (§7.6), and pointerShape matches the capability key and RuntimeCapabilities field.
#101's bespoke parse_osc() drew review for unbounded digit accumulation on `ESC ] 9999...`. The rebuild drops that parser and relies on #132's parse_osc_response(), which already bails once the OSC number passes 22 and caps payloads at MAX_RESPONSE. These tests pin that behavior for OSC 22 so it cannot regress: BEL and ST terminators, the "0" empty-stack reply, byte-at-a-time delivery, a broken ST, a 64-digit OSC number, OSC 220, and an over-long payload (no event, parser recovers for the next reply).
… them (renderer-spec §7.9)
Elements declare open(id, { pointerShape }). While
term.capabilities.pointerShape is true (raised by #132/#133 from the
probe's OSC 22 query reply), each render resolves the shape under the
pointer and appends `OSC 22 ; <shape> ST` to result.output when it
changes. The host keeps writing one buffer (cowboyd on #101). The
capability is the only gate; there is no createTerm option.
Everything lives in TypeScript; the wasm module and the packed encoding
are untouched, so there is no size or startup cost (#101 regressed
createTerm ~20% and grew the bundle 8.3 KB). Without the capability the
render path pays one boolean check and never reads pointerShape (a test
pins this with a counting getter). With it, the directive walk runs only
when the pointer is over something, and only a frame whose shape
changed copies output to append the sequence.
Resolution takes the last declaring id in Clay's pointer-over order:
pre-order within the topmost layer the pointer reaches, so the innermost
element wins and capture-mode floats hide what is beneath while
passthrough floats defer to it. snapshot() records its declared shapes
beside the packed bytes so pre-packed subtrees still participate.
Values outside the kitty/CSS vocabulary are ignored (and rejected by
validate()), so arbitrary strings never reach an OSC payload.
Restore (dreyfus92 on #101): set-only OSC 22 cannot pop, so the emitted
shape is the one piece of cross-frame state. It resets to "default"
when the pointer leaves or is omitted, and update() returns the reset
when the pointer-shape capability is withdrawn. Resize keeps it, since
the terminal's pointer did not change.
…e mode AGENTS.md's "renderer MUST NOT manage terminal state" invariant listed only the caret exception. Pointer shape is the second: the renderer owns it only while the pointerShape capability is true (renderer-spec §7.9). renderer-spec §7.9 now says resolution and output are identical in line mode, settling cowboyd's open question on #101. Bytes appear only when the caller passes `pointer`, which already implies an interactive terminal with mouse reporting, so a line-mode carve-out would protect nothing. The "emits in line mode" test in the feat commit pins it.
The OSC 22 query is answered by kitty alone; every other set-supporting terminal (ghostty, foot, xterm ≥ 367) never replies, so detection could never see them and the demos silently degraded in the most popular modern terminal. terminfo-spec §6.1 now carries a known-support table of terminals with primary evidence for OSC 22 set support — ghostty (its own docs), kitty (its own spec), foot (foot-ctlseqs(7)), and xterm (ctlseqs, version floor 367 via XTERM_VERSION). detectTerminal() seeds pointerShape from the environment; the probe reply remains authoritative evidence, and the manual CapabilityEvent opt-in stays for anything the table misses. mintty is deliberately excluded: it implements OSC 22 but its accepted names are Windows cursor names (hand, ibeam), not the CSS names §7.9 emits. Rio and Konsole lack primary evidence (Konsole is contradicted by ucs-detect, which — like our probe — tests the query form). The spec records the accepted false-positive trade: nested contexts that inherit the environment unchanged (Emacs term-mode) are granted by association; muxers and IDE terminals rewrite their identity and are naturally excluded.
Walks the full OSC 22 pointer-shape chain in the user's terminal — probe round-trip with per-query breakdown, parser CapabilityEvents, renderer emission — and separates query-less set-supporting terminals from terminals without OSC 22 at all. Built to debug the ghostty detection gap; kept as a general diagnostic for the capability layer.
debug-pointer-shape.ts walked the whole capability chain already; only its last section was pointer-shape specific. Renamed and generalized: it now prints the full static Capabilities at detection, the merged RuntimeCapabilities after folding probe events, and which fields the probe raised. The pointer-shape section stays as the end-to-end emission check (the one capability render() consumes today), with the verdict distinguishing probe-confirmed support from a known-support table grant.
CSS names the mouse-pointer property cursor and the insertion-point
properties caret-*, and the library already calls the insertion point
caret (text()'s caret declaration, §7.6) — so the element property now
matches CSS exactly: open(id, { cursor: "grab" }).
The capability layer keeps its names: the CapabilityEvent key stays
"pointer-shape", RuntimeCapabilities.pointerShape stays, and
pointer-shape.ts / PointerShape / POINTER_SHAPES / resolvePointerShape
stay — those name the kitty OSC 22 pointer-shape protocol, which is
what they are on the wire.
Minor changeset: we're 0.x.
…erer Synchronized output (§7.9) landed first, so pointer shape moves to §7.10 and §7.8 lists both as specified. cursor is now transferred to the WASM module like every other open() property, so a snapshot carries it in its packed bytes. That means pack() reads cursor regardless of capability; the gate is on emission only. With no post-hoc concatenation, render output is again always a view over renderer-owned memory (§7.3).
cursor was carried beside the packed ops (a shapes side-array on snapshots) and resolved in JS by rescanning ops against hovered ids. It now follows the caret model: pack() encodes it as PROP_CURSOR (one word, the shape's vocabulary index; unknown values omit the bit), and reduce() registers it with Clay_OnHover while decoding. Clay calls hover functions during Clay_SetPointerState in hit-test order, ancestors first, so the last call is the innermost declaring element and capture/passthrough floating behave as before. The pointer now enters through reduce() so the hit test runs after EndLayout and before the frame's bytes are finalized; OSC 22 is appended from C after the frame (after ESU when synchronized output wraps it). The emitted shape lives on the Clayterm instance. set_capabilities() reports when withdrawing pointerShape leaves a non-default shape on screen, and update() returns the reset. The native wrapper carries the emitted shape across re-init so a resize leaves it unchanged. Anonymous elements can now declare a cursor; the JS resolver matched by string id and missed them.
natemoo-re
force-pushed
the
feat/pointer-shape
branch
from
October 19, 2026 12:01
16fcdda to
96b42e3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supercedes #101.
Adds support for the OSC 22 cursor shapes, which supports a subset of the CSS
cursorkeywords.The public API is an element that accepts the pointer shape:
open(id, { cursor: "pointer" }). The renderer writes the escape sequence directly intoresult.output. This works in line mode and alt screens.Review comments from #101 have been addressed and regression tests added.
OSC 22 bytes are only emitted for terminals that report support (using the probe infrastructure added in #132) or have been documented to support the escape sequence without supporting the query form (ghostty).
Demos updated in #144.